A/B-тестирование¶

Задача

Оцените корректность проведения теста и проанализировать его результаты. Чтобы оценить корректность проведения теста:

  • удостоверьтесь, что нет пересечений с конкурирующим тестом и нет пользователей, участвующих в двух группах теста одновременно;

  • проверьте равномерность распределения пользователей по тестовым группам и правильность их формирования.

Техническое задание

  • Название теста: recommender_system_test ;

  • группы: А — контрольная, B — новая платёжная воронка;

  • дата запуска: 2020-12-07;

  • дата остановки набора новых пользователей: 2020-12-21;

  • дата остановки: 2021-01-04;

  • аудитория: в тест должно быть отобрано 15% новых пользователей из региона EU;

  • назначение теста: тестирование изменений, связанных с внедрением улучшенной рекомендательной системы;

  • ожидаемое количество участников теста: 6000.

  • ожидаемый эффект: за 14 дней с момента регистрации пользователи покажут улучшение каждой метрики не менее, чем на 10%:

  • конверсии в просмотр карточек товаров — событие product_page,

  • просмотры корзины — product card,

  • покупки — purchase.

In [1]:
#импорт библиотек
import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)
import pandas as pd
import scipy.stats as stats
import numpy as np
import seaborn as sn
from matplotlib import pyplot as plt
import datetime as dt
import math
import plotly.express as px
from datetime import timedelta, datetime
from plotly import graph_objects as go

Обзор данных¶

In [2]:
#загружаем данные
events = pd.read_csv('https://code.s3.yandex.net/datasets/final_ab_events.csv')
new_users = pd.read_csv('https://code.s3.yandex.net/datasets/final_ab_new_users.csv')
marketing_events = pd.read_csv('https://code.s3.yandex.net/datasets/ab_project_marketing_events.csv')
participants = pd.read_csv('https://code.s3.yandex.net/datasets/final_ab_participants.csv')
In [3]:
#функция для вывода основных методов при исследовании данных
def data_research(df):
    print('Вывод первых 5 строк датафрейма')
    print('*'*50)
    display(df.head())
    print('*'*50)
    print('Описание данных методом describe()')
    print('*'*50)
    display(df.describe().T)
    print('*'*50)
    print('Описание данных методом info()')
    print('*'*50)
    df.info()
    print('*'*50)
    print('Количество дубликатов')
    print('*'*50)
    print(df.duplicated().sum())
    print('*'*50)
    print('Количество пропусков')
    print('*'*50)
    print(df.isna().sum())
In [4]:
#изучаем events
data_research(events)
Вывод первых 5 строк датафрейма
**************************************************
user_id event_dt event_name details
0 E1BDDCE0DAFA2679 2020-12-07 20:22:03 purchase 99.99
1 7B6452F081F49504 2020-12-07 09:22:53 purchase 9.99
2 9CD9F34546DF254C 2020-12-07 12:59:29 purchase 4.99
3 96F27A054B191457 2020-12-07 04:02:40 purchase 4.99
4 1FD7660FDF94CA1F 2020-12-07 10:15:09 purchase 4.99
**************************************************
Описание данных методом describe()
**************************************************
count mean std min 25% 50% 75% max
details 62740.0 23.877631 72.180465 4.99 4.99 4.99 9.99 499.99
**************************************************
Описание данных методом info()
**************************************************
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 440317 entries, 0 to 440316
Data columns (total 4 columns):
 #   Column      Non-Null Count   Dtype  
---  ------      --------------   -----  
 0   user_id     440317 non-null  object 
 1   event_dt    440317 non-null  object 
 2   event_name  440317 non-null  object 
 3   details     62740 non-null   float64
dtypes: float64(1), object(3)
memory usage: 13.4+ MB
**************************************************
Количество дубликатов
**************************************************
0
**************************************************
Количество пропусков
**************************************************
user_id            0
event_dt           0
event_name         0
details       377577
dtype: int64

Описание данных

final_ab_events.csv — действия новых пользователей в период с 7 декабря 2020 по 4 января 2021 года.

Структура файла:

user_id — идентификатор пользователя;

event_dt — дата и время покупки;

event_name — тип события;

details — дополнительные данные о событии. Например, для покупок, purchase, в этом поле хранится стоимость покупки в долларах.

Колонка details иммет пропуски, вместо 440317 строк 62740. Но согласно техническому заданию, в колонку заполняются дополнительные сведения, и для каких-то типов событий эти сведения могут отсутсвовать. Тип столбца event_dt необходимо привести к специальному типу datetime.

In [5]:
#меняем тип данных даты
events['event_dt'] = pd.to_datetime(events['event_dt'], format='%Y-%m-%d %H:%M:%S')
In [6]:
print(f'Количество уникальных пользователей в events - {events["user_id"].nunique()} ')
Количество уникальных пользователей в events - 58703 
In [7]:
print(f'уникальные события в events - {events["event_name"].unique()} ')
уникальные события в events - ['purchase' 'product_cart' 'product_page' 'login'] 

Описание событий следующее:

конверсии в просмотр карточек товаров — событие product_page,

просмотры корзины — product card,

покупки — purchase,

регистрация - login.

In [8]:
#даты покупок
events['event_dt'].unique()
Out[8]:
array(['2020-12-07T20:22:03.000000000', '2020-12-07T09:22:53.000000000',
       '2020-12-07T12:59:29.000000000', ...,
       '2020-12-30T12:21:24.000000000', '2020-12-30T10:54:15.000000000',
       '2020-12-30T10:59:09.000000000'], dtype='datetime64[ns]')
In [9]:
#проверим для каких колонок заполнено details
for i in events['event_name'].unique():
    print(f'Для события - {i}')
    print(events.query('event_name==@i')['details'].unique())
Для события - purchase
[ 99.99   9.99   4.99 499.99]
Для события - product_cart
[nan]
Для события - product_page
[nan]
Для события - login
[nan]

Колонка details заполнена только для типа события - purchase (покупка). Покупки совершаются с 7 декабря 2020 по 30 декабря 2020.

In [10]:
#изучаем marketing_events
data_research(marketing_events)
Вывод первых 5 строк датафрейма
**************************************************
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
1 St. Valentine's Day Giveaway EU, CIS, APAC, N.America 2020-02-14 2020-02-16
2 St. Patric's Day Promo EU, N.America 2020-03-17 2020-03-19
3 Easter Promo EU, CIS, APAC, N.America 2020-04-12 2020-04-19
4 4th of July Promo N.America 2020-07-04 2020-07-11
**************************************************
Описание данных методом describe()
**************************************************
count unique top freq
name 14 14 St. Patric's Day Promo 1
regions 14 6 APAC 4
start_dt 14 14 2020-11-11 1
finish_dt 14 14 2020-12-01 1
**************************************************
Описание данных методом info()
**************************************************
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 14 entries, 0 to 13
Data columns (total 4 columns):
 #   Column     Non-Null Count  Dtype 
---  ------     --------------  ----- 
 0   name       14 non-null     object
 1   regions    14 non-null     object
 2   start_dt   14 non-null     object
 3   finish_dt  14 non-null     object
dtypes: object(4)
memory usage: 576.0+ bytes
**************************************************
Количество дубликатов
**************************************************
0
**************************************************
Количество пропусков
**************************************************
name         0
regions      0
start_dt     0
finish_dt    0
dtype: int64

Описание данных

ab_project_marketing_events.csv — календарь маркетинговых событий на 2020 год.

Структура файла:

name — название маркетингового события;

regions — регионы, в которых будет проводиться рекламная кампания;

start_dt — дата начала кампании;

finish_dt — дата завершения кампании.

In [11]:
#выведем всю таблицу для ознакомления
marketing_events
Out[11]:
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
1 St. Valentine's Day Giveaway EU, CIS, APAC, N.America 2020-02-14 2020-02-16
2 St. Patric's Day Promo EU, N.America 2020-03-17 2020-03-19
3 Easter Promo EU, CIS, APAC, N.America 2020-04-12 2020-04-19
4 4th of July Promo N.America 2020-07-04 2020-07-11
5 Black Friday Ads Campaign EU, CIS, APAC, N.America 2020-11-26 2020-12-01
6 Chinese New Year Promo APAC 2020-01-25 2020-02-07
7 Labor day (May 1st) Ads Campaign EU, CIS, APAC 2020-05-01 2020-05-03
8 International Women's Day Promo EU, CIS, APAC 2020-03-08 2020-03-10
9 Victory Day CIS (May 9th) Event CIS 2020-05-09 2020-05-11
10 CIS New Year Gift Lottery CIS 2020-12-30 2021-01-07
11 Dragon Boat Festival Giveaway APAC 2020-06-25 2020-07-01
12 Single's Day Gift Promo APAC 2020-11-11 2020-11-12
13 Chinese Moon Festival APAC 2020-10-01 2020-10-07

Дубликаты и пропуски отсуствуют в датафрейме. Столбцы start_dt и finish_dt необходимо привести к специальному типу datetime.

In [12]:
#меняем тип данных даты
marketing_events['start_dt'] = pd.to_datetime(marketing_events['start_dt'], format='%Y-%m-%d')
marketing_events['finish_dt'] = pd.to_datetime(marketing_events['finish_dt'], format='%Y-%m-%d')

Маркетинговые события проводятся в Европе, Северной Америке, СНГ и странах Азии.

Всего в датасете представлено 14 событий:

- промо-акции

    - день одиночки;

    - Международный день женщин;

    - Китайский новый год;

    - 4 июля;

    - пасха;

    - день Святого Патрика;

    - Рождество и Новый год.

- рекламные акции

    - черная пятница;

    - день труда (1 мая);

    - 9 мая;

- розыгрыши подарков

    - Новый год СНГ;

    - день святого валентина.

- фестивали

    - фестиваль середины осени;

    - фестиваль драконьих лодок.

Причем промоакция "Christmas&New Year Promo" (с 25 декабря по 3 января) пересеклась с датами проведения теста. Необходимо изучить визуализацию распределения событий и понять были ли в эти даты резкие скачки или резкое падения количества событий.

In [13]:
#изучаем new_users
data_research(new_users)
Вывод первых 5 строк датафрейма
**************************************************
user_id first_date region device
0 D72A72121175D8BE 2020-12-07 EU PC
1 F1C668619DFE6E65 2020-12-07 N.America Android
2 2E1BF1D4C37EA01F 2020-12-07 EU PC
3 50734A22C0C63768 2020-12-07 EU iPhone
4 E1BDDCE0DAFA2679 2020-12-07 N.America iPhone
**************************************************
Описание данных методом describe()
**************************************************
count unique top freq
user_id 61733 61733 028C841A61F126F5 1
first_date 61733 17 2020-12-21 6290
region 61733 4 EU 46270
device 61733 4 Android 27520
**************************************************
Описание данных методом info()
**************************************************
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 61733 entries, 0 to 61732
Data columns (total 4 columns):
 #   Column      Non-Null Count  Dtype 
---  ------      --------------  ----- 
 0   user_id     61733 non-null  object
 1   first_date  61733 non-null  object
 2   region      61733 non-null  object
 3   device      61733 non-null  object
dtypes: object(4)
memory usage: 1.9+ MB
**************************************************
Количество дубликатов
**************************************************
0
**************************************************
Количество пропусков
**************************************************
user_id       0
first_date    0
region        0
device        0
dtype: int64

Описание данных

final_ab_new_users.csv - все пользователи, зарегистрировавшиеся в интернет-магазине в период с 7 по 21 декабря 2020 года.

Структура файла:

user_id — идентификатор пользователя;

first_date — дата регистрации;

region — регион пользователя;

device — устройство, с которого происходила регистрация.

Пропуски отсутсвуют, дубликаты тоже. Тип столбца first_date необходимо привести к специальному типу datetime.

In [14]:
#меняем тип данных даты
new_users['first_date'] = pd.to_datetime(new_users['first_date'], format='%Y-%m-%d')
In [15]:
print(f'Количество уникальных пользователей в new_users - {new_users["user_id"].nunique()} ')
Количество уникальных пользователей в new_users - 61733 
In [16]:
#регионы пользователей
new_users["region"].unique()
Out[16]:
array(['EU', 'N.America', 'APAC', 'CIS'], dtype=object)
In [17]:
#устройства пользователей
new_users["device"].unique()
Out[17]:
array(['PC', 'Android', 'iPhone', 'Mac'], dtype=object)
In [18]:
#даты регистраций пользователей
new_users["first_date"].sort_values().unique()
Out[18]:
array(['2020-12-07T00:00:00.000000000', '2020-12-08T00:00:00.000000000',
       '2020-12-09T00:00:00.000000000', '2020-12-10T00:00:00.000000000',
       '2020-12-11T00:00:00.000000000', '2020-12-12T00:00:00.000000000',
       '2020-12-13T00:00:00.000000000', '2020-12-14T00:00:00.000000000',
       '2020-12-15T00:00:00.000000000', '2020-12-16T00:00:00.000000000',
       '2020-12-17T00:00:00.000000000', '2020-12-18T00:00:00.000000000',
       '2020-12-19T00:00:00.000000000', '2020-12-20T00:00:00.000000000',
       '2020-12-21T00:00:00.000000000', '2020-12-22T00:00:00.000000000',
       '2020-12-23T00:00:00.000000000'], dtype='datetime64[ns]')

Всего датасет содержит 61733 уникальных пользователей, привлеченных с 7 декабря 2020 по 23 декабря 2020. Пользователи используют следующие типов устройств: PC, Android, iPhone, Mac. Регионы привлеченных пользователей: EU, N.America, APAC, CIS.

Согласно техническому заданию дата остановки набора новых пользователей: 2020-12-21. Поэтому данный датасет придется отфильтровать на этапе проверки соответсвия данных техническому заданию.

In [19]:
#изучаем participants
data_research(participants)
Вывод первых 5 строк датафрейма
**************************************************
user_id group ab_test
0 D1ABA3E2887B6A73 A recommender_system_test
1 A7A3664BD6242119 A recommender_system_test
2 DABC14FDDFADD29E A recommender_system_test
3 04988C5DF189632E A recommender_system_test
4 482F14783456D21B B recommender_system_test
**************************************************
Описание данных методом describe()
**************************************************
count unique top freq
user_id 18268 16666 5424E9D321EC3567 2
group 18268 2 A 9655
ab_test 18268 2 interface_eu_test 11567
**************************************************
Описание данных методом info()
**************************************************
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 18268 entries, 0 to 18267
Data columns (total 3 columns):
 #   Column   Non-Null Count  Dtype 
---  ------   --------------  ----- 
 0   user_id  18268 non-null  object
 1   group    18268 non-null  object
 2   ab_test  18268 non-null  object
dtypes: object(3)
memory usage: 428.3+ KB
**************************************************
Количество дубликатов
**************************************************
0
**************************************************
Количество пропусков
**************************************************
user_id    0
group      0
ab_test    0
dtype: int64
In [20]:
#уникальные пользователи
participants['user_id'].nunique()
Out[20]:
16666
In [21]:
# проводимые тесты
participants['ab_test'].unique()
Out[21]:
array(['recommender_system_test', 'interface_eu_test'], dtype=object)
In [22]:
# группы пользователей
participants['group'].unique()
Out[22]:
array(['A', 'B'], dtype=object)
In [23]:
#изучим количество пользователей в нашем тесте
participants.query('ab_test=="recommender_system_test"').groupby('group').agg(users_by_group=('group','count')).reset_index()
Out[23]:
group users_by_group
0 A 3824
1 B 2877

Описание данных

/datasets/final_ab_participants.csv — таблица участников тестов.

Структура данных:

user_id — идентификатор пользователя;

ab_test — название теста;

group — группа пользователя

Пропуски и дубликаты отсутствуют. Типы данных соответсвуют описанию. Всего датафрейм содержит 18268 строк и 16666 уникальных пользователей. Имеются данные о двух A/B-тестах: recommender_system_test и interface_eu_test. Согласно техническому заданию нас интересует только recommender_system_test. Для данного теста пользователи поделены следующим образом: группа A - контрольная, имеет 3824 человека, группа B - новая платёжная воронка, содержит 2877 человек.

Вероятно, какие-то пользователи попали в два теста или в две группы.

Таким образом, мы изучили 4 датафрейма. Дубликаты отсуствуются во всех датасетах. Пропуски присуствуют в events в столбце details, но они обусловлены особенностью заполнения столбца. Кроме того, столбец details заполнен только для типа события - purchases (покупка).

Все столбцы с датой приведены к сепциальному типу datetime.

На этапе ознакомления с данными таблицы new_users было отмечено, что дата остановки набора новых пользователей 2020-12-23 не соответсвует техническому заданию - 2020-12-21.

Также количество уникальных пользователей в таблице participants не соответсвует количеству строк в таблице, необходимо проверить нет ли пересечений групп пользователей, или возможно это персечение тестов (и тогда нет необходимости удалять данные).

Корректность проведения теста¶

  • Соответствие данных требованиям технического задания.
In [24]:
#сохраним сырые данные
participants_row = participants
In [25]:
#начнем с первого требования, нас интересует только тест - recommender_system_test
participants = participants.query('ab_test=="recommender_system_test"')
In [26]:
print(f'В датафрейме было  {round((len(participants)/len(participants_row)*100),2)}% об интересующем нас тесте - recommender_system_test')
В датафрейме было  36.68% об интересующем нас тесте - recommender_system_test
In [27]:
#группы пользователей
participants_group  = participants.groupby('group').agg(users_by_group=('group','count')).reset_index()
participants_group
Out[27]:
group users_by_group
0 A 3824
1 B 2877

Таким образом, мы оставили в таблице participants только данные о тесте - recommender_system_test, он составляет 36% исходных данных.

В разделе "Аудитория теста" проверим нет ли пересечений групп теста "А" и "В", и нет ли персечений с конкурирующим тестом.

In [28]:
# проверим дату действий пользователя на соотвествие началу (2020-12-07) и концу тесту (2021-01-04)
print(f'Дата первого действия пользователя - {events["event_dt"].min()}')
print(f'Дата последнего действия пользователя - {events["event_dt"].max()}')
Дата первого действия пользователя - 2020-12-07 00:00:33
Дата последнего действия пользователя - 2020-12-30 23:36:33

Таким образом, дейстия пользователей не вышли за временные рамки проведения теста.

Согласно техническому заданию дата остановки набора новых пользователей - 21 декабря 2020 года. При обзоре данных мы вяснили, что в таблице new_users, которая содержит сведения о регистрации новых пользователей, последняя дата - 23 декабря 2020. Приведем данные таблицы new_users в соответсвие ТЗ.

In [29]:
#сохраним сырые данные
new_users_row = new_users
In [30]:
new_users = new_users.query('first_date<="2020-12-21"')
In [31]:
print(f'После фильтрации осталось  {round((len(new_users)/len(new_users_row)*100),2)}% ')
После фильтрации осталось  91.47% 
In [32]:
#определим процент новых пользователей из Европы
eu_new_users = new_users[new_users['region'] == 'EU']['user_id']
eu_users_from_test = participants.query('user_id in @eu_new_users')['user_id']

print(f'Доля от всех зарегистрированных из этого региона в период отбора пользователей:{round(eu_users_from_test.nunique() / eu_new_users.nunique()*100,2)}%')
print(f'Всего пользователей из Европы в тесте - {eu_users_from_test.nunique()}')
print(f'Всего новых пользователей из Европы - {eu_new_users.nunique()}')
Доля от всех зарегистрированных из этого региона в период отбора пользователей:15.0%
Всего пользователей из Европы в тесте - 6351
Всего новых пользователей из Европы - 42340
In [33]:
#распределение новых пользователей по регионами
new_users_by_regions = new_users.groupby('region').agg(count=('user_id', 'count'))
In [34]:
#проверим рспределение людей в нашем тесте по регионами
#объединим таблицы
check_users = participants.merge(new_users, on='user_id', how='left').reset_index(drop=True)
check_users.head()
Out[34]:
user_id group ab_test first_date region device
0 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 EU PC
1 A7A3664BD6242119 A recommender_system_test 2020-12-20 EU iPhone
2 DABC14FDDFADD29E A recommender_system_test 2020-12-08 EU Mac
3 04988C5DF189632E A recommender_system_test 2020-12-14 EU iPhone
4 482F14783456D21B B recommender_system_test 2020-12-14 EU PC
In [35]:
#сгруппируем пользователей по регионам и группам и посмотрим, как они распределены между группами
check_users = check_users.groupby(['group','region']).agg(user_count=('user_id','count')).reset_index()
check_users = check_users.merge(participants_group, how='left', on='group')
check_users
Out[35]:
group region user_count users_by_group
0 A APAC 37 3824
1 A CIS 25 3824
2 A EU 3634 3824
3 A N.America 128 3824
4 B APAC 35 2877
5 B CIS 30 2877
6 B EU 2717 2877
7 B N.America 95 2877
In [36]:
#посчитаем долю для каждого региона
check_users['percent'] = round(check_users['user_count']/check_users['users_by_group']*100,2)
check_users
Out[36]:
group region user_count users_by_group percent
0 A APAC 37 3824 0.97
1 A CIS 25 3824 0.65
2 A EU 3634 3824 95.03
3 A N.America 128 3824 3.35
4 B APAC 35 2877 1.22
5 B CIS 30 2877 1.04
6 B EU 2717 2877 94.44
7 B N.America 95 2877 3.30
In [37]:
#строим график
fig = px.bar(check_users, y='user_count', x='region', color='group', text='percent', title='Распределение пользователей регионов по группам теста')
fig.update_layout(yaxis_title = 'Количество пользователей', xaxis_title = 'Регион')
fig.show()
In [38]:
#строим график без EU
fig = px.bar(check_users.query('region != "EU"'), y='user_count', x='region', color='group', text='percent', title='Распределение пользователей регионов по группам теста')
fig.update_layout(yaxis_title = 'Количество пользователей', xaxis_title = 'Регион')
fig.show()

Пользователи распределены не совсем равномерно по группам теста, в то время как для СНГ и стра Азии - доля пользователей около 1%, для Северной Америки - 3,3% Возможно пользователи пользовались VPN и поэтому попали в другие регионы, а возможно при проведении теста случился сбой и новая версия показывалась и пользователям из других стран.

Примем решение оставить только пользователей из Европы, исключив СНГ, страны Азии и Северную Америку.

In [39]:
#отфильтруем новых пользователей по региону
new_users = new_users[new_users['region'] == 'EU']
new_users.shape[0]
Out[39]:
42340
In [40]:
#отфильтруем пользователей теста по региону
participants = participants.query('user_id in @eu_new_users')
participants.shape[0]
Out[40]:
6351

Аудитория ( 15% новых пользователей из региона EU) соответсвует ТЗ. Ожидаемое количество участников теста (6000) и лайфтайм 14 дней проверим в самом конце обработки данных.

  • Время проведения теста
In [41]:
print(f'Дата первого действия пользователя - {events["event_dt"].min()}')
print(f'Дата последнего действия пользователя - {events["event_dt"].max()}')
print(f'Даты регистрации пользователей с {new_users["first_date"].min()} по {new_users["first_date"].max()} ')
Дата первого действия пользователя - 2020-12-07 00:00:33
Дата последнего действия пользователя - 2020-12-30 23:36:33
Даты регистрации пользователей с 2020-12-07 00:00:00 по 2020-12-21 00:00:00 
In [42]:
#функция для проверки персечение дат
def intersect(df,t1end=dt.date(2021, 1, 4) ,t1start=dt.date(2020, 12, 7)):
    listik = []
    for i in df['name'].unique():
        print(f'для события - {i}')
        t2end = df.query('name==@i')['finish_dt'].max()
        t2start = df.query('name==@i')['start_dt'].max()
        if (t1start <= t2start <= t2end <= t1end) and ("EU" in ''.join(df.query('name==@i')['regions'].to_list())):
            print(f'Пересечение{t2start,t2end}')
            print('')
        elif (t1start <= t2start <= t1end) and ("EU" in ''.join(df.query('name==@i')['regions'].to_list())):
            print(f'Пересечение{t2start,t1end}')
            print('')
        elif (t1start <= t2end <= t1end)  and ("EU" in ''.join(df.query('name==@i')['regions'].to_list())):
            print(f'Пересечение{t1start,t2end}')
            print('')
        elif (t2start <= t1start <= t1end <= t2end) and ("EU" in ''.join(df.query('name==@i')['regions'].to_list())):
            print(f'Пересечение {t1start,t1end}')
            print('')
        else:
            print('Нет пересечений')
            print('')
    return 
In [43]:
intersect(marketing_events)
для события - Christmas&New Year Promo
Пересечение(Timestamp('2020-12-25 00:00:00'), Timestamp('2021-01-03 00:00:00'))

для события - St. Valentine's Day Giveaway
Нет пересечений

для события - St. Patric's Day Promo
Нет пересечений

для события - Easter Promo
Нет пересечений

для события - 4th of July Promo
Нет пересечений

для события - Black Friday Ads Campaign
Нет пересечений

для события - Chinese New Year Promo
Нет пересечений

для события - Labor day (May 1st) Ads Campaign
Нет пересечений

для события - International Women's Day Promo
Нет пересечений

для события - Victory Day CIS (May 9th) Event
Нет пересечений

для события - CIS New Year Gift Lottery
Нет пересечений

для события - Dragon Boat Festival Giveaway
Нет пересечений

для события - Single's Day Gift Promo
Нет пересечений

для события - Chinese Moon Festival
Нет пересечений

На период проведения теста попало маркетинговое событие "Christmas&New Year Promo". Проверим распределение событий по дням.

In [44]:
#объединим таблицы для графика
ab_test_temporary= participants.merge(events, on='user_id', how='left').reset_index(drop=True)
ab_test_temporary.head()
Out[44]:
user_id group ab_test event_dt event_name details
0 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 14:43:27 purchase 99.99
1 D1ABA3E2887B6A73 A recommender_system_test 2020-12-25 00:04:56 purchase 4.99
2 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 14:43:29 product_cart NaN
3 D1ABA3E2887B6A73 A recommender_system_test 2020-12-25 00:04:57 product_cart NaN
4 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 14:43:27 product_page NaN
In [45]:
#гистограмма по дате и времени
plt.figure(figsize=(15,5))
sn.histplot(data = ab_test_temporary, x='event_dt',bins=24*24, hue='group', element="step")
plt.title('Гистограмма распределения событий ')
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.grid()
plt.show()

По гистограмме видно, что в период проведения маркетинговых событий не наблюдается резких скачков или спадов. Заметно, что отсутствуют данные с 30 декабря по 4 января (по ТЗ тест еще длилися в это время, возможно это обусловлено технической ошибкой при выгрузке данных.

In [46]:
#сохраним сырые данные
events_row = events
In [47]:
#events = events.query('event_dt<"2020-12-25"')
#print(f'После фильтрации потеряли  {round((100 - len(events)/len(events_row)*100),2)}% ')
events.shape[0]
Out[47]:
440317
  • Аудитория теста
In [48]:
#проверим наличие пользователей в двух тестах
in_both_ab_groups = participants_row.groupby('user_id').agg({'ab_test':'nunique'}).query('ab_test > 1').reset_index()
len(in_both_ab_groups)
Out[48]:
1602
В таблице содержатся данные о двух тестах - 'recommender_system_test', 'interface_eu_test' . Даты проведения теста не известны, но мы знаем, что это конкурирующий тест, и он может оказывать влияние на наш. Рассмотрим, как пользователи распределены по грeппам в конкурирующем тесте.
In [49]:
#сохраним пользователей, попавших в обе группы
participants_both = participants_row[participants_row['user_id'].isin(in_both_ab_groups['user_id'])]
participants_both.sample(10)
Out[49]:
user_id group ab_test
16380 F19288FFDF6014FE A interface_eu_test
2116 B9AF175E0AAECD8C A recommender_system_test
7885 1D055BBA227A1E7F A interface_eu_test
1055 71D1FF7218FB3F1E A recommender_system_test
1656 F1B46AFD720C74D8 B recommender_system_test
4727 36EDA624DB7B7F90 A recommender_system_test
9410 CCAF138D12385DF2 A interface_eu_test
4770 7E19C338FE4D995A B recommender_system_test
14814 7E0D60AF41DECEC6 A interface_eu_test
3329 47219A3FA0F71DE0 B recommender_system_test

При решении того, каких пользователей конкурирующего теста мы отфильтруем, важно ли представлять, чем отличаются группы А и Б конкурирующего теста? Важно ли, равномерно ли они распределены между группами нашего теста?

  • Если пользователи попали в группу А конкурирующего теста, то можно их оставлять, так как это контрольная группа и на нее не оказывается никакого влияния.

  • А вот с пользователями группы В ситуация обратная, надо рассмотреть, сколько пользователей попали из конкурирующего теста в группу В. Посмотреть насколько равномерно они распределены в нашем тесте, если относительно равномерно, то их при можно оставить.

In [50]:
#пользователя группы В конкурирующего теста
participants_B =  participants_both.query('ab_test == "interface_eu_test" & group =="B"')
len(participants_B)
Out[50]:
783
In [51]:
#сохраним пользователей в лист
participants_B_list = participants_B['user_id']
In [52]:
#рассмотрим, как данные пользователи распределились по нашим группам
participants_recomm = participants.query('user_id in @participants_B_list and ab_test=="recommender_system_test"')\
                    .groupby(['ab_test', 'group']).agg({'user_id': 'nunique'})\
                    .reset_index()
participants_recomm
Out[52]:
ab_test group user_id
0 recommender_system_test A 439
1 recommender_system_test B 344

Проверим равномерность распределение пользователей в нашем тесте при помощи z-теста.

Сформулируем гипотезы.

Нулевая: доли пользователей в группах А и В равны.

Альтернативная: доли пользователей в группах различаются.

In [53]:
#статистический уровень значимости 
alpha = 0.05

event_1 = participants_recomm.query('group=="A"')['user_id'].max()
total_1 = participants.groupby('group').agg(users_by_group=('group','count')).reset_index()['users_by_group'][0]
event_2 = participants_recomm.query('group=="B"')['user_id'].max()
total_2 = participants.groupby('group').agg(users_by_group=('group','count')).reset_index()['users_by_group'][1]

#доля успехов в 1 группе
share_1 = event_1 / total_1
#доля успехов в 2 группе
share_2 = event_2 / total_2
#доля успехов в комбинированном датасете 
share_combined = (event_1 + event_2) / (total_1 + total_2)
#разница в долях между группами
diff = share_1 - share_2
#считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = diff / (share_combined * (1 - share_combined) * (1/total_1 + 1/total_2)) ** 0.5
#задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
dist = stats.norm(0, 1)
#считаем по статистичке p-value
p_value = 2 * (1 - dist.cdf(abs(z_value)))
print('p-значение: ', p_value)
#сравниваем p_value с уровнем значимости и делаем вывод по тесту
if p_value < alpha:
     print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
    print(
        'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными') 
p-значение:  0.4861791801372526
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
При помощи z-теста мы подтвердили, что пользователи распределены в нашем тесте относительно равномерно, значит можем не исключать их.
In [54]:
#проверим наличие пользователей в двух группах
in_both_groups = ab_test_temporary.groupby('user_id').agg({'group':'nunique'}).query('group > 1')
len(in_both_groups)
Out[54]:
0
In [55]:
#проверим равномерность распределение пользователей в нашем тесте
#подготовим таблицу для теста
our_test = participants.groupby('group').agg(users_by_group=('user_id','nunique')).reset_index()
our_test['all'] = our_test['users_by_group'].sum()
our_test
Out[55]:
group users_by_group all
0 A 3634 6351
1 B 2717 6351

Проверим равномерность распределение пользователей в нашем тесте при помощи z-теста.

Сформулируем гипотезы.

Нулевая: доли пользователей в группах А и В равны.

Альтернативная: доли пользователей в группах различаются.

In [56]:
#статистический уровень значимости 
alpha = 0.05

event_1 = our_test.query('group=="A"')['users_by_group'].max()
total_1 = our_test.query('group=="A"')['all'].max()
event_2 = our_test.query('group=="B"')['users_by_group'].max()
total_2 = our_test.query('group=="A"')['all'].max()

#доля успехов в 1 группе
share_1 = event_1 / total_1
#доля успехов в 2 группе
share_2 = event_2 / total_2
#доля успехов в комбинированном датасете 
share_combined = (event_1 + event_2) / (total_1 + total_2)
#разница в долях между группами
diff = share_1 - share_2
#считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = diff / (share_combined * (1 - share_combined) * (1/total_1 + 1/total_2)) ** 0.5
#задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
dist = stats.norm(0, 1)
#считаем по статистичке p-value
p_value = 2 * (1 - dist.cdf(abs(z_value)))
print('p-значение: ', p_value)
#сравниваем p_value с уровнем значимости и делаем вывод по тесту
if p_value < alpha:
     print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
    print(
        'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными') 
p-значение:  0.0
Отвергаем нулевую гипотезу: между долями есть значимая разница

Всего в группах 6351 пользователь. Пользователей в двух группах "А" и "В" - нет. Пользователи между группами "А" и "Б" распределены неравномерно.

In [57]:
#объединим таблицы для теста
ab_test = (participants.merge(events, on='user_id', how='left')
                  .merge(new_users, on='user_id', how='left')
                  .reset_index(drop=True)
                 )
ab_test.head(10)
Out[57]:
user_id group ab_test event_dt event_name details first_date region device
0 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 14:43:27 purchase 99.99 2020-12-07 EU PC
1 D1ABA3E2887B6A73 A recommender_system_test 2020-12-25 00:04:56 purchase 4.99 2020-12-07 EU PC
2 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 14:43:29 product_cart NaN 2020-12-07 EU PC
3 D1ABA3E2887B6A73 A recommender_system_test 2020-12-25 00:04:57 product_cart NaN 2020-12-07 EU PC
4 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 14:43:27 product_page NaN 2020-12-07 EU PC
5 D1ABA3E2887B6A73 A recommender_system_test 2020-12-25 00:04:57 product_page NaN 2020-12-07 EU PC
6 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 14:43:27 login NaN 2020-12-07 EU PC
7 D1ABA3E2887B6A73 A recommender_system_test 2020-12-25 00:04:56 login NaN 2020-12-07 EU PC
8 A7A3664BD6242119 A recommender_system_test 2020-12-20 15:46:06 product_page NaN 2020-12-20 EU iPhone
9 A7A3664BD6242119 A recommender_system_test 2020-12-21 00:40:59 product_page NaN 2020-12-20 EU iPhone
In [58]:
#сохраним сырые данные
ab_test_row = ab_test
In [59]:
#добавим столбец с  разницей между регистрацией и событием
ab_test['difference'] = ab_test['event_dt'] - ab_test['first_date']
In [60]:
#проверим  пользователей, которые не совершали событий
ab_test[ab_test['event_dt'].isna()]
Out[60]:
user_id group ab_test event_dt event_name details first_date region device difference
32 482F14783456D21B B recommender_system_test NaT NaN NaN 2020-12-14 EU PC NaT
59 057AB296296C7FC0 B recommender_system_test NaT NaN NaN 2020-12-17 EU iPhone NaT
66 E9FA12FAE3F5769C B recommender_system_test NaT NaN NaN 2020-12-14 EU Android NaT
67 FDD0A1016549D707 A recommender_system_test NaT NaN NaN 2020-12-13 EU PC NaT
68 547E99A7BDB0FCE9 A recommender_system_test NaT NaN NaN 2020-12-12 EU iPhone NaT
... ... ... ... ... ... ... ... ... ... ...
26219 C5E1BD2400840B30 B recommender_system_test NaT NaN NaN 2020-12-17 EU iPhone NaT
26220 EA29547AB3C0CB9C B recommender_system_test NaT NaN NaN 2020-12-14 EU iPhone NaT
26242 9A44E27079666291 B recommender_system_test NaT NaN NaN 2020-12-08 EU Android NaT
26243 9C2D0067A991213E B recommender_system_test NaT NaN NaN 2020-12-07 EU PC NaT
26258 A23B0A7FFF375BFF B recommender_system_test NaT NaN NaN 2020-12-12 EU Android NaT

2870 rows × 10 columns

Так как пользователи без событий все равно не будут отражаться в продуктовой воронке, а также они не несут никакой полезной информации, то при фильтрации событиий по горизонту 14 дней от даты регистрации, сразу избавимся и от этих пользователей.
In [61]:
print(f'Исходное количество строк в общей таблице - {ab_test.shape[0]}')
Исходное количество строк в общей таблице - 26290
Мы должны отфильтровать события, которые пользователи совершили после 14 дней от регистрации

Так как нас интересует улучшение метрики только в течении 14 дней от регистрации, мы должны отбросить события, которые произошли после 14 дней от того момента, как был зарегистрирован пользователь, который их совершил. Нам необходимо, чтобы лайфтайм событий, которые будут влиять на наш тест, укладывался в установленный горизонт событий.

In [62]:
#отфильтруем события, которые пользователи совершили после 14 дней от регистрации
ab_test = ab_test.query('difference <= "14 days"')
print(f'Количество строк в общей таблице - {ab_test.shape[0]}')
Количество строк в общей таблице - 22620
In [63]:
#проверим в течение какого времени пользователи совершают большую часть событий
days = ["1 day","2 day","3 day", "4 day", "5 day", "6 days","7 days", "8 days", "9 days", "10 days", "11 days", "12 days", "13 days","14 days"]
for j in ab_test['event_name'].unique():
    #print('Для события', j)
    #print('')
    count_event = []
    for i in days:
        ab_tem = ab_test.query('event_name==@j')
        #print(f'Количество событий {ab_tem.query("difference < @i")["difference"].count()}')
        count_event.append(ab_tem.query("difference < @i")["user_id"].count())
    plt.figure(figsize=(10,5))  
    plt.title(f'Динамика по лайфтайму для события - {j}')
    plt.xlabel('Горизонт')
    plt.ylabel('Количество событий')
    sn.lineplot(x=days, y=count_event, color = 'blue')
    plt.xticks(rotation=15)
    plt.grid()
В основном пользователи совершают большую часть событий с 5 по 9 день жизни. В идеале мы должны дать возможность пользователям совершать события все 14, но в таком случае мы потерям слишком много пользователей, поэтому мы оставляем всех пользователей, которые не прожили 14 дней.

Ожидаемое количество участников теста проверим после построения продуктовой воронки, значение конверсии в покупку для группы А и будет исходным значением конверсии.

Таким образом, мы отфильтровали данные по:

  • дате окончания набора новых пользователей ( 21 декабря 2020);

  • дате начала и окончания теста (в целом не выходим за границы ТЗ, но почему-то дата последнего события - 30 декабря, а тест продолжался до 4 января, часть данных была потеряна?);

  • региону проведения теста - Европа.

Подтвердили, что в тест попали 15% новых пользователей из Европы.

Проверили пересечение с маркетинговыми события, и выяснили, что попали на проведение Рождественского&Новогоднего промо. По гистограмме распределения событий по дням всплеска/спада не было выявлено.

Не стали исключать пользователей, попавших в конкурирующий тест, так как они распределены равномерно между группами нашего теста.

Исключили события, у которых горизонт превышал 14 дней. Но оставили пользователей не прожившихся 14 дней, так как в среднем большая часть событий совершается в течение 5 дней. Исключив пользователей, мы бы заметно снизили мощность теста.

Также исключили пользователей без событий, в результате остался 3481 пользователь (в группе А - 2604, в группе B - 877).

Исследовательский анализ¶

In [64]:
#удалим лишние столбцы
ab_test.drop(['difference', 'region'], axis= 1 , inplace= True)
/Users/elizavetapuhova/opt/anaconda3/envs/da_practicum_env/lib/python3.9/site-packages/pandas/core/frame.py:4308: SettingWithCopyWarning:


A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy

In [65]:
#количество участников в тесте
ab_test['user_id'].nunique()
Out[65]:
3481
In [66]:
#количество пользователей в группах
ab_test.groupby('group')['user_id'].nunique()
Out[66]:
group
A    2604
B     877
Name: user_id, dtype: int64
In [67]:
#построим продуктовую воронку для групп A и В
funnel_event_A = (ab_test
                  .query('group == "A"')
                  .groupby('event_name')
                  .agg({'user_id':'nunique'})
                  .reset_index()
                  .sort_values(by='user_id', ascending=False)
                  .reset_index(drop=True))
funnel_event_A.columns = ['event_name','user_count']
funnel_event_A.head()
Out[67]:
event_name user_count
0 login 2604
1 product_page 1685
2 purchase 833
3 product_cart 782
In [68]:
funnel_event_B = (ab_test
                  .query('group == "B"')
                  .groupby('event_name')
                  .agg({'user_id':'nunique'})
                  .reset_index()
                  .sort_values(by='user_id', ascending=False)
                  .reset_index(drop=True))
funnel_event_B.columns = ['event_name','user_count']
funnel_event_B
Out[68]:
event_name user_count
0 login 876
1 product_page 493
2 purchase 249
3 product_cart 244
In [69]:
#доля каждого события от общего числа
round(ab_test.groupby(by='event_name')['user_id'].count().sort_values(ascending=False)/len(ab_test)*100,2)
Out[69]:
event_name
login           45.17
product_page    27.95
purchase        13.72
product_cart    13.17
Name: user_id, dtype: float64
In [70]:
#цвета диаграммы
colors = sn.color_palette('pastel')[ 0:5 ]
#круговая диаграмма
fig, ax = plt.subplots(1, 2, figsize=(10,5))
ax[0].set_title("Распределение пользователей по событиям для группы А", size=9)
ax[0].pie(funnel_event_A['user_count'],labels = funnel_event_A['event_name'],colors = colors, autopct='%.0f%%')
ax[1].set_title("Распределение пользователей по событиям для группы B", size=9)
ax[1].pie(funnel_event_B['user_count'],labels = funnel_event_B['event_name'],colors = colors, autopct='%.0f%%')
plt.show()
Интересно, что доля события - purchase (покупка) больше доли product card (просмотр коризны) для обеих групп теста. Значит пользователи могут совершать заказ без перехода в корзину.

Примем следйющие порядок событий:

регистрация(вход на сайт) - login.

конверсии в просмотр карточек товаров — событие product_page,

просмотры корзины — product card,

покупки — purchase.

In [71]:
#добавим столбец с долей пользователей для каждого события 
funnel_event_B['users_share'] = round(funnel_event_B['user_count']/ab_test.query('group=="B"')['user_id'].nunique()*100,2)
funnel_event_B
Out[71]:
event_name user_count users_share
0 login 876 99.89
1 product_page 493 56.21
2 purchase 249 28.39
3 product_cart 244 27.82
In [72]:
#добавим столбец с долей пользователей для каждого события 
funnel_event_A['users_share'] = round(funnel_event_A['user_count']/ab_test.query('group=="A"')['user_id'].nunique()*100,2)
funnel_event_A
Out[72]:
event_name user_count users_share
0 login 2604 100.00
1 product_page 1685 64.71
2 purchase 833 31.99
3 product_cart 782 30.03
In [73]:
#сделаем правильный порядок событий
funnel_event_A = funnel_event_A.reindex([0,1,3,2])
funnel_event_B = funnel_event_B.reindex([0,1,3,2])
In [74]:
funnel_event_A
Out[74]:
event_name user_count users_share
0 login 2604 100.00
1 product_page 1685 64.71
3 product_cart 782 30.03
2 purchase 833 31.99
In [75]:
#определим долю пользователей, которые переходят с одного этапа на следующий
funnel_event_A['share_previous'] = round(funnel_event_A['user_count'] / funnel_event_A['user_count'].shift(1)*100,2)
funnel_event_A.loc[0,'share_previous'] = 100
funnel_event_A
Out[75]:
event_name user_count users_share share_previous
0 login 2604 100.00 100.00
1 product_page 1685 64.71 64.71
3 product_cart 782 30.03 46.41
2 purchase 833 31.99 106.52
In [76]:
#определим долю пользователей, которые переходят с одного этапа на следующий
funnel_event_B['share_previous'] = round(funnel_event_B['user_count'] / funnel_event_B['user_count'].shift(1)*100,2)
funnel_event_B.loc[0,'share_previous'] = 100
funnel_event_B
Out[76]:
event_name user_count users_share share_previous
0 login 876 99.89 100.00
1 product_page 493 56.21 56.28
3 product_cart 244 27.82 49.49
2 purchase 249 28.39 102.05
In [77]:
#строим воронку для группы A
fig = go.Figure(go.Funnel(
    y = funnel_event_A['event_name'],
    x = funnel_event_A['user_count'],
    textinfo = "value+percent initial+percent previous"))
fig.update_layout(title_text='Воронка событий для группы A')
fig.show()
In [78]:
#строим воронку для группы В
fig = go.Figure(go.Funnel(
    y = funnel_event_B['event_name'],
    x = funnel_event_B['user_count'],
    textinfo = "value+percent initial+percent previous"))
fig.update_layout(title_text='Воронка событий для группы В')
fig.show()
Согласно ТЗ за 14 дней с момента регистрации в системе пользователи должны показать улучшение каждой метрики не менее, чем на 10%.

Сравнив воронки событий для групп А и В мы видим, что для обоих групп показатель конверсии в покупку низкий около 30% (32% для группы А, 28% для группы В), и на каждом из шагов у нас "отсеивается" примерно половина пользователей. Увеличения конверсии в покупку на 10% не произошло, а она наоборот упала на 12%.

Изменение конверсии в воронке в выборках на разных этапах:

  • для группы А: 65% перешли на карточку товара -> 30% перешли в продуктовую корзину -> 32% совершили покупку.

  • для группы B: 56% перешли на карточку товара -> 28% перешли в продуктовую корзину -> 28% совершили покупку.

Еще раз отметим наше наблюдение, что количество пользователей, совершивших покупку больше пользователей, перешедших в корзину. Вероятно, пользователи могут покупать товар напрямую с карточки (страницы) товара.

  • Соответствие данных требованиям технического задания
Определим минимальный размер выборки по калькулятору для определения относительного различия между группами теста в 10%, при базовой конверсии 32% (конверсия группы А), мощности теста в 80% и уровне значимости в 5%.

%D0%A1%D0%BD%D0%B8%D0%BC%D0%BE%D0%BA%20%D1%8D%D0%BA%D1%80%D0%B0%D0%BD%D0%B0%202023-07-11%20%D0%B2%2012.26.31.png

Таким образом, количество участников в тесте до удаления данных - 6351, и после удаления - 3481, соответсвует минимальному размеру выборки.
In [79]:
#распределение событий по дням
sn.set_style("whitegrid")
plt.figure(figsize=(10,5))
sn.histplot(data = ab_test, x='event_dt',bins=24*24, hue='group', palette='muted')
plt.grid()
plt.title('Гистограмма распределения событий по дням')
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.axvspan('2020-12-25', '2021-01-03', color='green', alpha=.1)
#plt.xlim('2020-12-07 00:00:00','2021-01-03')
plt.tight_layout()
plt.show()
По гистограмме распределения событий по дням видно, что "Рождественское&Новогоднее промо" не оказало влияния на проведение теста - нет резких скачков и спадов.

Основные пики актиности пользователей заметны 14 декбря и 21 декабря - дата окончания набора новых пользователей.

In [80]:
#среднее количество событий на пользователя
print('Среднее количество событий на пользователя для группы A', round(ab_test.query('group=="A"').groupby('user_id', as_index=False).agg({'event_name':'count'}).mean(),2))
#средее количество уникальных событий на пользователя
print('Среднее количество событий на пользователя для группы A', round(ab_test.query('group=="A"').groupby('user_id', as_index=False).agg({'event_name':'nunique'}).mean(),2))
Среднее количество событий на пользователя для группы A event_name    6.85
dtype: float64
Среднее количество событий на пользователя для группы A event_name    2.27
dtype: float64
In [81]:
#среднее количество событий на пользователя
print('Среднее количество событий на пользователя для группы B', round(ab_test.query('group=="B"').groupby('user_id', as_index=False).agg({'event_name':'count'}).mean(),2))
#средее количество уникальных событий на пользователя
print('Среднее количество событий на пользователя для группы B', round(ab_test.query('group=="B"').groupby('user_id', as_index=False).agg({'event_name':'nunique'}).mean(),2))
Среднее количество событий на пользователя для группы B event_name    5.46
dtype: float64
Среднее количество событий на пользователя для группы B event_name    2.12
dtype: float64
In [82]:
#количество событий на пользователя - данные для гистограммы
event_by_user = ab_test.groupby(['group','user_id'], as_index=False).agg(count_event=('event_name','count')).reset_index()
In [83]:
plt.figure(figsize=(15, 5))
plt.title('Распределение количества событий на пользователя по группам теста')
sn.histplot(data = event_by_user, x='count_event', bins=50, hue='group', kde=True)
plt.xlabel('Количество событий')                  
plt.ylabel('Количество пользователей')     
#plt.legend()
plt.show()

Заметно, что в среднем на пользователя группы А приходится больше событий, чем на пользователя группы В.

Среднее количество событий на пользователя для группы A - 6,85.

Среднее количество событий на пользователя для группы A - 2,27.

Среднее количество событий на пользователя для группы B - 5,46.

Среднее количество событий на пользователя для группы B - 2,12.

Особенности, выявленные в ходе анализа:

  • В данных присутсвует второй тест interface_eu_test, у которого регион проведения также Европа, но даты проведения неизвестны. Из ТЗ мы знаем, что это конкурирубщий тест, и он может оказывать влияние на наш. Не стали исключать пользователей, попавших в конкурирующий тест, так как они распределены равномерно между группами нашего теста.

  • В пользотелей теста попали не только жители Европы, но и стран СНГ, Азии и Северной Америки. Это может быть сбой при проведении теста, либо пользователи использовали VPN.

  • Согласно ТЗ тест проводится с 7 декбря 2020 по 4 января 2021, но в данных отсутствует период с 30-12 по 04-01. С чем это связано? Ошибка при выгрузке данных или технический сбой при проведении теста?

  • Тестирование recommender_system_test пересеклось с "Рождественским&Новогодним промо", но судя по распределению событий по дням особого влияние не оказало.

  • Количество пользователей соответсвует ожидаемому по а/б калькулятору, но пользователи распределены между группами неравномерно. Группа А - 2604, группа В - 877. Равный размер групп наиболее оптимален при проведении теста.

  • Продуктовая воронка выглядит следующим образом: вход -> карточка товара -> переход в корзину -> покупка.

При постороении воронок по группам А и B было замечено, что доля пользователей, которые переходят в корзину, меньше доли пользователей, совершивших покупки. Выдвинули предположение о том, что пользователи могут совершать покупки напрямую из карточки товара, минуя корзину.

  • В среднем на пользователя группы А приходится больше действий, чем на пользователя группы В. Возможно, это связано с с внедрением улучшенной рекомендательной системой и пользователи реже стали совершать какие-либо действия (реже переходят на карточку товара, добавляют сразу нужный товар в корзину).

Изучение результатов эксперимента¶

Для сравнения долей перехода с одного события на другое в двух группах будем использовать z-тест. Проведем сравнение пропорций для основных метрик - product_page, product_cart и purchase.

Сформулируем гипотезы.

Нулевая гипотеза: различий в долях конкретного события между группами нет.

Альтернативная: различия в долях между группами есть.

В паре будет 3 гипотезы (конверсии будут сраниваться для 3 событий в продуктовой воронке). Таким образом, суммарно будет провередено 1*3=3 теста.

При проведении множественного теста, чтобы снизить вероятность ошибки первого рода (нулевая гипотеза неверно отвергнута), необходимо применить поправку к уровню значимости. Будем использовать поправку Бонферрони - уровни значимости в каждом из m сравнений в m раз меньше, чем уровень значимости, требуемый при единственном сравнении.

Уровень значимости примем - 5%.

In [84]:
#количество пользователей в каждой группе
users_by_group = ab_test.pivot_table(index='group',values = 'user_id', aggfunc = 'nunique').reset_index()
users_by_group
Out[84]:
group user_id
0 A 2604
1 B 877
In [85]:
#группируем данные по группам и событиям для z-теста, сортируем по популярности событий
for_z_test = ab_test.query('event_name != "login"').pivot_table(index = 'event_name', columns = 'group', values = 'user_id', aggfunc = 'nunique',margins=True)
for_z_test = for_z_test.sort_values(by="A", ascending=False)
for_z_test
Out[85]:
group A B All
event_name
All 2120 666 2786
product_page 1685 493 2178
purchase 833 249 1082
product_cart 782 244 1026
In [86]:
def z_test(event_1, event_2, total_1, total_2, bonferroni, alpha=0.05):
    '''
функция для проведения z-теста, где event_1 - количество пользователей, совершивших действие в 1 группе
event_2 - количество пользователей, совершивших действие в 2 группе
total_1 - суммарное количество пользователей в 1-й группе
total_2 - суммарное количество пользователей в 2-й группе
bonferroni - количество сравниваемых пар
alpha - уровень статистической значимости
    '''
#статистический уровень значимости с учетом поправки
    bonferroni_alpha = alpha / bonferroni
#доля успехов в 1 группе
    share_1 = event_1 / total_1
#доля успехов в 2 группе
    share_2 = event_2 / total_2
#доля успехов в комбинированном датасете 
    share_combined = (event_1 + event_2) / (total_1 + total_2)
#разница в долях между группами
    diff = share_1 - share_2
#считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = diff / (share_combined * (1 - share_combined) * (1/total_1 + 1/total_2)) ** 0.5
#задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    dist = stats.norm(0, 1)
#считаем по статистичке p-value
    p_value = 2 * (1 - dist.cdf(abs(z_value)))
    print('p-значение: ', p_value)
#сравниваем p_value с уровнем значимости и делаем вывод по тесту
    if p_value < bonferroni_alpha:
        print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
    else:
        print(
        'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными') 
        print('')
In [87]:
#запустим функцию для сравнения долей групп по всем событиям
for event in for_z_test.index:
    if event!='All':
        print('Результаты теста для события',event )
        z_test(for_z_test.loc[event, 'A'], for_z_test.loc[event, 'B'], for_z_test.loc['All','A'], for_z_test.loc['All','B'], 3)
        print('')
Результаты теста для события product_page
p-значение:  0.0029370596204436605
Отвергаем нулевую гипотезу: между долями есть значимая разница

Результаты теста для события purchase
p-значение:  0.37888740748690086
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными


Результаты теста для события product_cart
p-значение:  0.9070540547116017
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными


Нет оснований отвергать нулевую гипотезу для событий "purchase" и "product_cart", статистически значимой разницы не выявлено, а значит улучшение рекомендательной системы не повлияло на поведение пользователей.

Для события "product_page" статистически значимые различия между долями групп А и В обнаружены.

Выводы

В результате выявленных особенностей на этапе исследовательского анализа, можно сделать вывод о том, что тест проведен с нарушениями:

  • Тест проведен в период повышенного спроса обусловленного сезонностью - увеличение количества покупок в связи с Новым годом и Рождеством.В предновогодние/новогодние праздники покупательская активность сильно меняется. Все гипотезы, которые мы сейчас подтвердили, могут показать совершенно другие результаты, например, весной, когда покупательское поведение будет другое.

  • Тестирование recommender_system_test пересеклось с "Рождественским&Новогодним промо", чего необходимо избегать при проведении а/б теста.

  • Выяснили, что в регионе проходил еще один конкурирующий тест (даты его проведения не знаем). После дополнительной проверки мы пришли к выводу, что пользователи, попавшие в оба теста, равномерно распределены между группами нашего теста. Поэтому не стали исключать их из анализа.

  • В датафрейме нет данных о событиях с 31 декабря по 04 января 2021 - дата окончания теста согласно ТЗ. Непонятно, почему данные отсутсвуют.

  • Количество уникальных пользователей в группах А и В сильно различается (2604 и 877 соответственно). Причем изначально было 6701 пользователь (A - 3634, В - 2717), но как оказалось больше половины пользователей не совершали действий.

Таким образом, все особенности могли оказать влияние на результаты теста.

Проанализировав события были принята следующая последовательность вороки: Регистрация -> Кароточка товара -> Переход в коризину -> Покупка. При построении продуктовой воронки оказалось, что пользователей, совершивших покупки больше, чем пользователей перешедших в корзину. При составлении ТЗ необходимо было указать, могут ли пользователи напрямую совершать покупку с карточки товара, чтобы исключить двойственную интерпретацию наблюдений. А вдруг это сбой в записи данных?

Результаты проверки гипотезы о равенстве долей уникальных пользователей следующие:

  • для события "product_page" статистически значимые различия между долями групп А и В обнаружены ( по продуктовой воронке конверсия относительно первого этапа для группы А - 65%, для группы В - 56%);

  • статистически значимой разницы не выявлено в долях уникальных пользователей между группами А и В для событий "purchase" и "product_cart".

Таким образом, по имеющимся результатам теста улучшеная рекомендательная система не повлияла на поведение пользователей (и, следовательно, не улучшила метрики).

В виду отмеченных особенностей рекомендуется признать тест некорректым, доработать механизм формирования тестовых групп и запустить тест заново, избегая наложения с маркетинговыми акциями и явными сезонными всплесками.

In [ ]: